Basesailor USB CメスからUSBオス変換アダプター 2パック、が対応可能 iPhone 16 15 14 13 12 Mini Pro Maxミニプロマックス、Airpods 4 iPad 10 Air、Samsung Galaxy S25 Plus S24 Ultra Watch 9 8 7 Z Fold Flip 6 Typeタイプ CからUSB A充電器プラグケーブルコンバータ
¥699 (2025年5月5日 13:15 GMT +09:00 時点 - 詳細はこちら価格および発送可能時期は表示された日付/時刻の時点のものであり、変更される場合があります。本商品の購入においては、購入の時点で当該の Amazon サイトに表示されている価格および発送可能時期の情報が適用されます。)対応 iPad 11世代 / 10世代 ガラスフィルム (2025/2022モデル) ガイド枠付き 【2枚セット-日本旭硝子素材】対応 iPad第10世代 2022 iPad第11世代A16 10.9インチ 保護フィルム iPad10 iPad11 フィルム 強化ガラス スマートタブレット 第11世代2025 第10世代2022 液晶保護フィルム ガイド枠 { 全面保護 2.5D 硬度9 H 耐衝撃 飛散防止 貼り付け簡単 自動吸着 気泡ゼロ 指紋防止 ラウンドエッジ加工 超薄0.26mm 超高質感 スマートタブレット SENTM-2IP10D-1
¥998 (2025年5月5日 13:15 GMT +09:00 時点 - 詳細はこちら価格および発送可能時期は表示された日付/時刻の時点のものであり、変更される場合があります。本商品の購入においては、購入の時点で当該の Amazon サイトに表示されている価格および発送可能時期の情報が適用されます。)【コスパの神】HJCE iPad用ペン 2025年発売iPad/iPad Air対応 アップルペンシル 超急速充電 タッチペン スタイラスペン ピクセルレベルの精度と低いレイテンシー ipadペンシル対応 磁気吸着/傾き感知/パームリジェクション 2018年以降iPad/iPad Pro/iPad Air/iPad mini対応(ホワイト)
¥1,599 (2025年5月5日 13:15 GMT +09:00 時点 - 詳細はこちら価格および発送可能時期は表示された日付/時刻の時点のものであり、変更される場合があります。本商品の購入においては、購入の時点で当該の Amazon サイトに表示されている価格および発送可能時期の情報が適用されます。)
はじめに
MCP(Model Context Protocol) は、最近、頻繁に聞くようになった技術用語です。
MCP自体はただのC/Sでのプロトコルですが、そもそもAIと既存の様々なシステム資産との標準化された接続を目的に生まれたものであり、AIエージェントという文脈の中でも、とても有用なプロトコルです。
AIエージェントのデモアプリを、そのMCPで実装してみました。
MCPの公式SDKは使用しましたが、ちょっと前にSDKに統合されたFastMCPは使用しておらず、MCPサーバーとMCPクライアントを全部自前で実装しました。
本稿では、SDKに統合されたFastMCPを除いた「元祖SDK」での、
実装コード、実装のルールや注意点、その理由や背景
を記してあります。
読者は、流行りつつあるFastMCPを使用していない本来の「素」のMCPとはどういうものかを知ることができ、今後、それはMCPと関わるうえで何らかのプラスになるでしょう。
筆者自身も、「素」のMCPガチ実装から得るところが多かったです。
冒頭のキャプチャ画面を見ると、やっていることはおバカっぽく見えますですが、
中身の実装コードやその説明は、それなりに考え抜いたものです。
例えば、GPTを用いたFunction callingとMCPの絡ませ方のあたりは、同等のものが無く、他に転用できるようなものだと思っています。
※「AIエージェント」の定義
本稿での定義 | 一般的定義 |
---|---|
ユーザーの代理人(エージェント)として挙動 | 環境を知覚し、その知覚に基づいて行動を選択し、環境に影響を与えるシステム |
以下①→②→③が自律して行われる。 | 以下①→②→③が自律して行われる。 |
①ユーザーの自然言語での依頼に応じて | ①知覚:環境(=ユーザーの発話など)から入力を得る |
②自ら必要な処理を選択し | ②判断や推論:その入力を処理・解釈する |
③それを実行する | ③行動:何らかの出力や操作を実行する |
前提
-
MCP公式ドキュメントを事前に読むことをお勧めします
MCPについて、しっかりとした説明をする余裕(紙面スペース&筆者の気力)が、本稿にはもうありません・・・。
本稿では超簡単な説明しかできませんので、公式ドキュメントを事前に読んでいただくのがいいです。 - MCPクライアント⇔MCPサーバー間の通信は標準入出力に限定
MCPでは、MCPクライアントとMCPサーバー間の通信のTransport Layerとして、主に「標準入出力」と「HTTP with SSE」の2通りを想定しています。
本稿では標準入出力に限定します。
両者は利用想定もパラダイムも全く違い、MCPの公式SDKのカバー範囲も違います。統一的に扱うことはできません。
※本稿は、MCPの実装の説明のみをします。ですので、本WEBアプリの実行情報やMCPとは無関係なUI関連のコードの提供はしません。
イメージで速習「MCPとは?」
クリックして、MCPとは何か、イメージで感じてみる
冒頭で書いた通り、MCPそのものの説明は、ここでMCPを説明する画像や表を紙芝居のように並べるだけにとどめます。
MCPを知らない人は、これら紙芝居を眺めて、MCPとはどういうものか、イメージで感じてください。
MCP公式ドキュメント
MCP公式ドキュメント Core architecture
MCP is an open protocol that standardizes how applications provide context to LLMs. Think of MCP like a USB-C port for AI applications. Just as USB-C provides a standardized way to connect your devices to various peripherals and accessories, MCP provides a standardized way to connect AI models to different data sources and tools.
The Model Context Protocol (MCP) is built on a flexible, extensible architecture that enables seamless communication between LLM applications and integrations.
MCP用語
プレイヤー3人「Host」「MCPクライアント」「MCPサーバー」
プレイヤー | 現実に即した定義 |
---|---|
Host | アプリケーション全体。MCPクライアントを含む。 |
MCPクライアント | 別プロセスにいるMCPサーバーとMCPに従ってやり取りする、C/Sのクライアント側。 仕事は、 ・ツールを実行させる、リソースを得る、など ・(AIエージェントの場合)LLMと協働してFunction callingを行う |
MCPサーバー | 別プロセスにいるMCPクライアントとMCPに従ってやり取りする、C/Sのサーバー側。 仕事は、MCPクライアントの求めに応じてタスクを実行すること(ツールを実行する、リソースを返す、など)。 具体的には、以下4つ。 ・全ツール(関数)の仕様のリストを提供 ・特定のツールを実行 ・全リソースの付属情報(メタデータ)のリストを提供 ・特定のリソースを提供 |
「ツール」と「リソース」
ツール/ リソース |
現実に即した定義 |
---|---|
ツール | MCPサーバーが保持する機能。いわゆる関数。 実行する対象。 |
リソース | MCPサーバーがアクセスできる何らかのデータやデータの状態。 知る対象。 |
<本題の前に>MCPの有用なユースケース
クリックして、前座の話を聞いてみる
ここでは、クライアント⇔サーバー間のProtocolでしかないMCP(Model Context Protocol)の有用なユースケースの1例を取り上げます。
まずは、AIが無い状態での、MCPの有用なユースケースの1例を見てみましょう。
MCPの有用なユースケース1例(MCPを導入したC/S、AI無し)
今、異なる2つの系統のシステム「販売系」「勘定系」が混在しているとします。
それぞれのAPIは、それぞれ独自仕様、独自プロトコルとします。
これらを、ユーザーに近いサイドでは、全く同一のものとして扱いたい。
そういう要求が出てくるのは必然でしょう。
そういう時に、MCPというプロトコルが有用となる場合があります。
「AI」はどこにも出てきていません。
図で示した通り、
ユーザーに近いサイドのMCPクライアントでは、異なる系統のAPIなんてことは全く意識することなく、それらを「MCPツール」として一括平等に扱うことができています。
ポイントは、
・共通プロトコルMCPで呼び出せるMCPツール群を単一のMCPサーバーが提供する
・MCPクライアントは、その単一MCPサーバーから提供されるそれらMCPツール群を一括平等に扱える
です。
上図の例は、MCPの効用を最大化するため、敢えて、異なるシステム発祥の、独自仕様・独自プロトコルのAPI群を単一MCPサーバーで束ねる、という例にしました。
が、そうでなくてもよく、上記2ポイントが実現されていることが重要です。
AI無しでも、十分MCPの効用があります。
これら2ポイントは、次のAIエージェント化のための必須条件にもなります。
というわけで、このMCPを導入したC/Sを、思い切ってAIエージェント化してみましょう。
MCPの有用なユースケース1例(MCPを導入したC/S、AIエージェント化後)
ユーザーが自然言語で業務の指示をし、AIエージェントがそれに応える、というものを想定します。
LLMがユーザー発話から必要なMCPツールを必要なだけ選択し、MCPクライアントがそれらMCPツールをMCPサーバーに実行させます。
このようなことができるのは、
・共通プロトコルMCPで呼び出せるMCPツール群を単一のMCPサーバーが提供しており、
・MCPクライアントがそれらMCPツール群を一括平等に扱える
からです。
AIであるLLMと既存のシステム資産を連携させるのに、MCPという共通のプロトコルが基盤になっている、ということが目視できたと思います。
AIエージェントアプリ 全体の概要
このミニWEBアプリ「玉の箱を管理するAIエージェント」は、大きく以下の2つの独立した機能で構成されています。
1つ目の機能は、本アプリのメインとなるAIエージェントの機能です。ユーザーの依頼にもとづき、玉を入れる仮想上の箱を管理します。
2つ目の機能は、ユーザーと上記AIエージェントの会話を別のAIが評論する、というものです。
どちらもMCPに準拠したC/Sですが、1つ目のメイン機能の方がAIエージェントです。
それぞれをまとめるとこうなります。
機能 | 特徴 | 概要 |
---|---|---|
メイン機能: AIエージェントが玉の箱を管理 |
・同一マシン内での標準入出力を介したMCP準拠のC/S ・AIエージェント、LLMにgpt-4o |
ユーザーの自然言語での依頼に応じて、AIエージェントが仮想上の箱に玉を入れたり、箱の中の玉の個数を数えたり、箱の中の玉を検索したりする。 |
サブ機能: 別AIがユーザーとAIエージェントとの会話を評論 |
・同一マシン内での標準入出力を介したMCP準拠のC/S ・AIエージェントではなく、MCP準拠のただのC/S |
ユーザーとAIエージェントとの会話に、別AI(LLMはgpt-4.1)が上から目線で評論を加える。 |
2機能のモジュール構成です。
アプリの中に、独立した2つのMCP準拠のC/S(標準入出力)があるのがわかります。
先に、メイン機能「AIエージェントが玉の箱を管理」の実装を見てみます。
その次に、サブ機能「別AIがユーザーとAIエージェントとの会話を評論」の実装を見ることにします。
メイン機能「AIエージェントが玉の箱を管理」について
挙動
一意の文字列が書いてある玉を入れる、仮想上の箱があります。
ユーザー(「あなた」)は、AIエージェントに対して、この箱や玉に関する依頼を自然言語で行います。
依頼の基本形は、
玉に文字列を書いて箱に入れる、箱から特定の文字列が書かれた玉を探し出す、箱の中の玉の数を数える、
などです。
1回の依頼の中に複数の依頼を含む場合にも、AIエージェントは1回で対応します。
1回の依頼の中に複数の依頼を含む例
あなた:玉に中曽根って書いて箱に入れてくれ。鳩山(由)という玉はある?
AI:玉に「中曽根」と書いて箱に入れました。「鳩山(由)」と書いてある玉はあります。
MCPクライアントとMCPサーバー
MCPクライアントとMCPサーバーは、同一マシン内の別プロセスで走ります。
両者の通信は、標準入出力を介して行われます。
まずは、MCPサーバー(mcp_server_ball.py)から実装を見て行きましょう。
メイン機能「AIエージェントが玉の箱を管理」のMCPサーバーの実装
クリックして、メイン機能(AIエージェント)のMCPサーバーの実装を見る
MCPサーバーは、MCPクライアントの求めに応じて、実際に箱の中に玉を入れたり、玉を検索したりします(ツールの実行)。
MCPサーバーが持つツール
ツールは2個です。
ツール名 | 機能 |
---|---|
add_ball | 玉に指定された文字列を書いて箱に入れる。 ball.txtに、指定された文字列の1行を書き加える。 |
get_balls_status | 玉の箱の状態を見る。指定された文字列の玉があるか、箱に何個玉があるか、など。 ball.txtを読む。 |
「玉の箱」の実体は、ball.txtというファイルで、中身は、1行=1個の玉に書かれた文字列、です。
MCPサーバー「mcp_server_ball.py」 コード全部
メイン機能「AIエージェントが玉の箱を管理」のMCPサーバー(mcp_server_ball.py)
import os
import json
import logging
import asyncio
from datetime import datetime
from typing import Dict, List, Optional, Any, Union
from mcp.server import Server
from mcp.server.lowlevel.server import NotificationOptions
import mcp.types as types
from pydantic import AnyUrl
from mcp.types import Resource
# ロギング設定(出力フォーマット追加)
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s [MCP-SERVER-BALL] %(message)s"
)
logger = logging.getLogger("mcp-server-ball")
# 玉の箱の実体
BALLS_FILE = "balls.txt"
# サーバーの初期化
server = Server("mcp-container-server")
# リソース定義 - URIによって識別されるリソース。ここでは空の辞書で生成しておく。
resources = {}
def initialize_files():
"""ファイルが存在しない場合は作成する"""
if not os.path.exists(BALLS_FILE):
with open(BALLS_FILE, 'w', encoding='utf-8') as f:
pass
def read_file(file_path: str) -> list[str]:
"""ファイルから行ごとに読み込む"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
return [line.strip() for line in f.readlines() if line.strip()]
except Exception as e:
logger.error(f"ファイル読み込みエラー: {e}")
return []
def write_to_file(file_path: str, text: str) -> bool:
"""ファイルに新しい行を追加する"""
try:
with open(file_path, 'a', encoding='utf-8') as f:
f.write(f"{text}\n")
return True
except Exception as e:
logger.error(f"ファイル書き込みエラー: {e}")
return False
def check_text_exists(file_path: str, text: str) -> bool:
"""ファイル内に特定のテキストが存在するか確認する"""
items = read_file(file_path)
return text in items
async def get_balls_resource() -> str:
"""リソース関数 箱のデータリソースを取得してJSON文字列を返す"""
items = read_file(BALLS_FILE)
data = {
"items": items,
"count": len(items)
}
return json.dumps(data, ensure_ascii=False)
async def get_box_appearance_resource() -> str:
"""リソース関数 箱の外観のJSON文字列を返す"""
data = {
"length": "50cm",
"width": "60cm",
"height": "70cm",
"color": "transparent"
}
return json.dumps(data, ensure_ascii=False)
async def add_ball(text: str) -> Dict[str, Any]:
"""
玉に文字を書いて箱に入れる関数
Args:
text: 玉に書く文字
Returns:
status: 処理結果のステータス
data: 追加した玉のデータ
"""
# 既に同じ文字の玉があるかチェック
if check_text_exists(BALLS_FILE, text):
return {"status": "error", "data": {"error": "既に存在する文字", "text": text}}
if write_to_file(BALLS_FILE, text):
return {"status": "success", "data": {"text": text}}
else:
return {"status": "error", "data": {"error": "玉の追加に失敗しました"}}
async def get_balls_status(search_text: Optional[str] = None, count_only: bool = False) -> Dict[str, Any]:
"""
箱の状態を取得する関数
Args:
search_text: 検索する文字(オプション)
count_only: 数だけを返すかどうか(オプション)
Returns:
status: 処理結果のステータス
data: 取得した玉のデータや統計情報
"""
# 玉のリストを取得
balls = read_file(BALLS_FILE)
# 玉の総数
total_count = len(balls)
# count_only が True なら玉の数だけを返す
if count_only:
return {"status": "success", "data": {"count": total_count}}
# 特定の文字を検索
if search_text:
found = search_text in balls
return {"status": "success", "data": {"text": search_text, "found": found}}
return {"status": "success", "data": {"balls": balls, "count": total_count}}
# リソース関数を登録
resources["mcp://resources/balls"] = get_balls_resource
resources["mcp://resources/box_appear"] = get_box_appearance_resource
# 玉の箱の実体ファイルの初期化
initialize_files()
# 全ツールの仕様のリスト
@server.list_tools()
async def list_tools() -> list[types.Tool]:
"""
利用可能なツールの仕様のリストを返す
AIエージェントでは、LLMがその仕様のリストを使用する
ユーザーの依頼を実現するために、LLMは、どのツールが適切か判断し、そのツールの引数を生成する
"""
return [
types.Tool(
name="add_ball",
description="玉に文字を書いて箱に入れる。(プロンプト例:玉に鈴木と書いて箱に入れて。)",
inputSchema={
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "箱に入れる玉に書く文字。"
}
},
"required": ["text"]
}
),
types.Tool(
name="get_balls_status",
description="箱に入っている玉や箱の状態を取得する。(プロンプト例:鈴木と書いてある玉はある?)(プロンプト例:今、玉は箱にいくつある?)",
inputSchema={
"type": "object",
"properties": {
"search_text": {
"type": "string",
"description": "この文字が書かれた玉を箱の中から検索(オプション)。"
},
"count_only": {
"type": "boolean",
"description": "箱の中の玉の数を取得するか(オプション)。"
}
}
}
)
]
# 特定のツールの呼び出し
@server.call_tool()
async def call_tool(name: str, arguments: Dict[str, Any]) -> list[Union[types.TextContent, types.ImageContent, types.EmbeddedResource]]:
"""
指定されたツールを引数を使って実行する
LLMが選択したツールと生成した引数に基づいて処理を行う
"""
# 各ツールを呼び出し
result = None
if name == "add_ball":
text = arguments.get("text")
result = await add_ball(text)
elif name == "get_balls_status":
search_text = arguments.get("search_text")
count_only = arguments.get("count_only", False)
result = await get_balls_status(search_text, count_only)
else:
result = {"status": "error", "data": {"error": "不明なツール"}}
# 結果を返す
return [types.TextContent(type="text", text=json.dumps(result, ensure_ascii=False))]
# 全リソースの付属情報のリスト
@server.list_resources()
async def list_resources_info() -> list[Resource]:
return [
Resource(
uri="mcp://resources/balls",
name="箱の状態",
description="現在、箱に入っている玉の情報(JSON)",
mimeType="application/json"
),
Resource(
uri="mcp://resources/box_appear",
name="箱の外観",
description="箱のサイズと色(JSON)",
mimeType="application/json"
)
]
# 特定のリソースの取得
@server.read_resource()
async def read_resource(resource_uri: AnyUrl) -> str:
str_resource_uri = str(resource_uri)
if str_resource_uri in resources:
resource_getter = resources[str_resource_uri]
if resource_getter is None:
logger.error("登録されているリソース関数無し")
raise ValueError(f"このURIで登録されているリソース関数がありません。: {str_resource_uri}")
# resource_getterが直接シリアライズした文字列を返すのでそのまま返す
resource_ret = await resource_getter()
return resource_ret
else:
raise ValueError(f"リソースが見つかりません: {str_resource_uri}")
# メイン関数 - MCPサーバーが独立プロセスとしてスクリプト実行された時の事実上のエントリーポイント
async def main():
"""
MCPサーバーを実行するメイン関数
独立したプロセスとしてスクリプト実行された際のエントリーポイント
"""
# MCPサーバー実行
from mcp.server.stdio import stdio_server
try:
# MCPクライアントと標準入出力を介したやり取りができる状態にする
async with stdio_server() as (read_stream, write_stream):
from mcp.server.models import InitializationOptions
# MCPサーバーを実際に動かして、標準入出力を介した待ち受け状態を作る
await server.run(
read_stream,
write_stream,
InitializationOptions(
server_name="mcp-container-server",
server_version="1.0.0",
capabilities=server.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={}
)
)
)
except Exception as e:
logger.error(f"MCPサーバー(玉の箱の管理)の起動中にエラーが発生しました: {e}")
raise
# MCPサーバーが独立プロセスとしてスクリプト実行された時のエントリーポイント
if __name__ == "__main__":
asyncio.run(main())
MCPサーバー実装のポイント
MCPサーバー実装のポイントを列挙していきます。
MCPサーバー実装の際の参考にしてください。
その1:if __name__ == "__main__"
でサーバーを起動、MCPクライアントからの要求待ち受け状態を作る
その2:MCPサーバーに必須の@server.
付き4関数を必ず実装
その3:(AIエージェント限定)list_tool()のdescriptionは、LLMが迷わないように明確に書く
その1:if __name__ == "__main__"
でサーバーを起動、MCPクライアントからの要求待ち受け状態を作る
該当箇所
# メイン関数 - MCPサーバーが独立プロセスとしてスクリプト実行された時の事実上のエントリーポイント
async def main():
"""
MCPサーバーを実行するメイン関数
独立したプロセスとしてスクリプト実行された際のエントリーポイント
"""
# MCPサーバー実行
from mcp.server.stdio import stdio_server
try:
# MCPクライアントと標準入出力を介したやり取りができる状態にする
async with stdio_server() as (read_stream, write_stream):
from mcp.server.models import InitializationOptions
# MCPサーバーを実際に動かして、標準入出力を介した待ち受け状態を作る
await server.run(
read_stream,
write_stream,
InitializationOptions(
server_name="mcp-container-server",
server_version="1.0.0",
capabilities=server.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={}
)
)
)
except Exception as e:
logger.error(f"MCPサーバー(玉の箱の管理)の起動中にエラーが発生しました: {e}")
raise
# MCPサーバーが独立プロセスとしてスクリプト実行された時のエントリーポイント
if __name__ == "__main__":
asyncio.run(main())
MCPサーバーが1番最初にしなければならないことは、
MCPクライアントからの(標準入出力を介した)要求を待ち受ける状態を作る
ということです。
それを、if __name__ == "__main__"
で行います。
個々のコードがやっていることは、コード中のコメントを参照してください(その程度の理解で十分)。
MCPサーバーを実装する場合は、上記の該当箇所丸パクリで構いません。
MCPクライアント側で、MCPサーバーを別プロセスでスクリプトとして実行すると(後のMCPクライアント実装において、このやり方を示します)、
MCPサーバーのif __name__ == "__main__"
が必ず走ることになります。
そして無事に、MCPサーバーがMCPクライアントからの(標準入出力を介した)要求を待ち受ける状態になります。
その2:MCPサーバーに必須の@server.
付き4関数を必ず実装
MCPサーバーに必須の@server.
付き4関数とは、MCPサーバーが担う4つの役割をそれぞれ実行する関数であり、具体的には以下です。
MCPサーバーに必須の関数 (デコレーター名) |
その意味、役割、存在意義 |
---|---|
@server.list_tools() |
全ツールの仕様のリスト(MCP形式)をMCPクライアントに返す。 そのリストは、自分がやれる仕事とその依頼の仕方の一覧表みたいなもの。 AIエージェントにおけるMCPサーバーでは、MCPクライアントから必ず呼ばれる。 |
@server.call_tool() |
MCPクライアントの要求により、特定のツールを実行する。 MCPクライアントは、必ずこの @server.call_tool() を介して、MCPサーバーの特定のツールを実行する。 |
@server.list_resources() |
全リソースの付属情報のリストをMCPクライアントに返す。 |
@server.read_resource() |
特定のリソースをMCPクライアントに返す。 |
MCPサーバーの一般的な役割は、表中の青字で示した4つです。
それぞれの役割に、それ専用の@server.
付き関数が1:1で対応しているのがわかります。
ですので、この4関数を必ず実装する必要があります。
「@server.list_tools()
」「@server.call_tool()
」「@server.list_resources()
」「@server.read_resource()
」というデコレーター名は厳守ですが、@表記の1行下の実際の関数名は自由に付けてもいいです。そっちの方は、別プロセスにいるMCPクライアントからは使いようが無いからです。
「@server.list_tools()
」「@server.call_tool()
」「@server.list_resources()
」「@server.read_resource()
」は、これらデコレーター名を変えてもダメだし、4関数のどれか1つが欠けてもダメです。
これらのデコレーター名と関数の存在の双方が、MCPのSDK及びMCPのプロトコルでの約束事です。
このデコレーター名「@server.
」は、MCPのSDKのものです。
別プロセスにいるMCPクライアントが標準入出力を介してMCPサーバーのこれらの4関数をコールしてその結果を受け取る際の、MCPの「プロトコル」の低階層部分をSDKが代わりにやってくれる、というものです。
例えば、プロトコルでは、MCPクライアント⇔MCPサーバー間は、JSON-RPC2.0形式で行え、とあります。
が、SDKを使用すると、JSON-RPC2.0での通信なんて、我々は実装しなくていいです。
必然的に、
別プロセスにいるMCPクライアントから標準入出力経由で呼べる関数は、これら「@server.
」付きの4関数のみ
ということになります。
その3:(AIエージェント限定)list_tool()のdescriptionは、LLMが迷わないように明確に書く
該当箇所 @server.list_tools()
# 全ツールの仕様のリスト
@server.list_tools()
async def list_tools() -> list[types.Tool]:
"""
利用可能なツールの仕様のリストを返す
AIエージェントでは、LLMがその仕様のリストを使用する
ユーザーの依頼を実現するために、LLMは、どのツールが適切か判断し、そのツールの引数を生成する
"""
return [
types.Tool(
name="add_ball",
description="玉に文字を書いて箱に入れる。(プロンプト例:玉に鈴木と書いて箱に入れて。)",
inputSchema={
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "箱に入れる玉に書く文字。"
}
},
"required": ["text"]
}
),
types.Tool(
name="get_balls_status",
description="箱に入っている玉や箱の状態を取得する。(プロンプト例:鈴木と書いてある玉はある?)(プロンプト例:今、玉は箱にいくつある?)",
inputSchema={
"type": "object",
"properties": {
"search_text": {
"type": "string",
"description": "この文字が書かれた玉を箱の中から検索(オプション)。"
},
"count_only": {
"type": "boolean",
"description": "箱の中の玉の数を取得するか(オプション)。"
}
}
}
)
]
この関数@server.list_tool()
は「@server.
」付きなので、別プロセスにいるMCPクライアントから標準入出力経由で呼ぶことができます。
この関数が実際にMCPクライアントに返しているのは以下のものです。
@server.list_tools()
の戻り値 ツール一覧のlist(MCP形式)
@server.list_tools() の戻り値 ツール一覧のlist(MCP形式)
[
{
"name": "add_ball",
"description": "玉に文字を書いて箱に入れる。(プロンプト例:玉に鈴木と書いて箱に入れて。)",
"inputSchema": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "箱に入れる玉に書く文字。"
}
},
"required": [
"text"
]
}
},
{
"name": "get_balls_status",
"description": "箱に入っている玉や箱の状態を取得する。(プロンプト例:鈴木と書いてある玉はある?)(プロンプト例:今、玉は箱にいくつある?)",
"inputSchema": {
"type": "object",
"properties": {
"search_text": {
"type": "string",
"description": "この文字が書かれた玉を箱の中から検索(オプション)。"
},
"count_only": {
"type": "boolean",
"description": "箱の中の玉の数を取得するか(オプション)。"
}
}
}
}
]
上記のように、このMCPサーバーが提供する全ツール分の”仕様”をMCPクライアントに返します。
いわば、このMCPサーバーが持つ全ツールの「仕様書」「APIリファレンス」のようなものです(MCP形式)。
これを、MCPクライアントに返します。
MCPクライアントでは、ユーザーから自然言語での依頼があった際、その依頼と一緒にこの全ツールの「仕様書」もLLMに投入して、LLMに適切なツールを選択させ、そのツールの引数も生成させます。
LLMは、この全ツールの「仕様書」のうち、各ツールの「description」を見てどのツールが適切か判断し、各ツールの各引数の「description」などを見て引数を生成します。(詳細)
だからこそ、
この2種類のdescriptionは、LLMがわかるように、ちゃんと書く
ということになるのです。
※本稿のAIエージェント機能はシンプルなので、descriptionの工夫の余地はあまりありませんでした。なのであまり良いお手本ではないです。
その他、個々の実装
@server.call_tool()のasync def call_tool(・・)
該当箇所 @server.call_tool()
# 特定のツールの呼び出し
@server.call_tool()
async def call_tool(name: str, arguments: Dict[str, Any]) -> list[Union[types.TextContent, types.ImageContent, types.EmbeddedResource]]:
"""
指定されたツールを引数を使って実行する
LLMが選択したツールと生成した引数に基づいて処理を行う
"""
# 各ツールを呼び出し
result = None
if name == "add_ball":
text = arguments.get("text")
result = await add_ball(text)
elif name == "get_balls_status":
search_text = arguments.get("search_text")
count_only = arguments.get("count_only", False)
result = await get_balls_status(search_text, count_only)
else:
result = {"status": "error", "data": {"error": "不明なツール"}}
# 結果を返す
return [types.TextContent(type="text", text=json.dumps(result, ensure_ascii=False))]
この関数は「@server.
」付きなので、別プロセスにいるMCPクライアントから標準入出力経由で呼ぶことができます。
MCPクライアントから指示された特定のツールを実行します。
MCPクライアントから、ツール名とそのツールの引数を受け取って、そのツール名のツールを実行します。
このMCPサーバーでは、戻り値はreturn [mcp.types.TextContent(type="text", text=json.dumps(result, ensure_ascii=False))]
にしています。どんなツールの実行を要求されても、その実行結果は必ずJSON文字列を含むmcp.types.TextContent
オブジェクト1個のみのlistとなります。
このcall_toolのシグネチャの最後の方 -> list[Union[mcp.types.TextContent, mcp.types.ImageContent, mcp.types.EmbeddedResource]]:
は規定のもので変えてはいけませんが、これを見ると、MCP汎用的には、mcp.types.TextContent
オブジェクト、 mcp.types.ImageContent
オブジェクト、mcp.types.EmbeddedResource
オブジェクトが、いくつでも入り乱れてlistに入って返ってくる可能性もある、ということがわかります。
このMCPサーバーでは、mcp.types.TextContent
オブジェクト1個のみのlistを返します。
@server.list_resources()のasync def list_resources_info()
該当箇所 @server.list_resources()
# 全リソースの付属情報のリスト
@server.list_resources()
async def list_resources_info() -> list[Resource]:
return [
Resource(
uri="mcp://resources/balls",
name="箱の状態",
description="現在、箱に入っている玉の情報(JSON)",
mimeType="application/json"
),
Resource(
uri="mcp://resources/box_appear",
name="箱の外観",
description="箱のサイズと色(JSON)",
mimeType="application/json"
)
]
この関数は「@server.
」付きなので、別プロセスにいるMCPクライアントから標準入出力経由で呼ぶことができます。
MCPサーバーが持つ全リソース分の付属情報(メタデータ)を、MCPクライアントに返します。
各リソースの個々の付属情報(メタデータ)を、全リソース分、束ねてリストにしたものをMCPクライアントに返す、という意味です。
デコレーター名「@server.list_resources()
」がとても誤解を招くのですが、リソース本体は含まれないので注意してください。「@server.list_resources_info()
」と脳内変換した方がいいかもしれない。
@server.read_resource()のasync def read_resource(・・)
該当箇所@server.read_resource()を含めた関連個所
# リソース定義 - URIによって識別されるリソース。ここでは空の辞書で生成しておく。
resources = {}
async def get_balls_resource() -> str:
"""リソース関数 箱のデータリソースを取得してJSON文字列を返す"""
items = read_file(BALLS_FILE)
data = {
"items": items,
"count": len(items)
}
return json.dumps(data, ensure_ascii=False)
async def get_box_appearance_resource() -> str:
"""リソース関数 箱の外観のJSON文字列を返す"""
data = {
"length": "50cm",
"width": "60cm",
"height": "70cm",
"color": "transparent"
}
return json.dumps(data, ensure_ascii=False)
# リソース関数を登録
resources["mcp://resources/balls"] = get_balls_resource
resources["mcp://resources/box_appear"] = get_box_appearance_resource
# 特定のリソースの取得
@server.read_resource()
async def read_resource(resource_uri: AnyUrl) -> str:
str_resource_uri = str(resource_uri)
if str_resource_uri in resources:
resource_getter = resources[str_resource_uri]
if resource_getter is None:
logger.error("登録されているリソース関数無し")
raise ValueError(f"このURIで登録されているリソース関数がありません。: {str_resource_uri}")
# resource_getterが直接シリアライズした文字列を返すのでそのまま返す
resource_ret = await resource_getter()
return resource_ret
else:
raise ValueError(f"リソースが見つかりません: {str_resource_uri}")
この関数は「@server.
」付きなので、別プロセスにいるMCPクライアントから標準入出力経由で呼ぶことができます。
MCPクライアントからリソースの一意識別子であるURIを受け取って、そのリソースをMCPクライアントに返します。
このMCPサーバーでは、リソース追加時の拡張性とか、URI文字列埋め込みでの条件分岐を避ける、などの理由で「ディスパッチテーブル方式」を取っています。
が、こんな工夫は大してメリットは無いので、URI埋め込みでベタに条件分岐でもいいと思います。
そもそも、MCPサーバーによって、保持するリソースは千差万別なので、実装方法に一律の正解や模範はありません。
以上で、
メイン機能「AIエージェントが玉の箱を管理」のMCPサーバーの実装の説明は終わりです。
次は、同じメイン機能のMCPクライアントの実装です。
メイン機能「AIエージェントが玉の箱を管理」のMCPクライアントの実装
クリックして、メイン機能(AIエージェント)のMCPクライアントの実装を見る
AIエージェント機能におけるMCPクライアントの主な仕事は
・LLMと協働してFunction callingを行う
・LLMの指示通りにMCPサーバーにツールを実行させる
の2点です。
これらを中心に実装していきます。
MCPクライアント「mcp_client_ball.py」 コード全部
メイン機能「AIエージェントが玉の箱を管理」のMCPクライアント(mcp_client_ball.py)
import asyncio
import json
import os
import logging
from mcp.client.session import ClientSession
from mcp.client.stdio import StdioServerParameters, stdio_client
from openai import AsyncOpenAI
from dotenv import load_dotenv
from pydantic import AnyUrl, parse_obj_as
# 環境変数の読み込み
load_dotenv()
env_vars = os.environ.copy()
# ロギング設定
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s [MCP-CLIENT-BALL] %(message)s"
)
logger = logging.getLogger("mcp-client-ball")
# MCPサーバーの設定(グローバル変数として定義)
SERVER_COMMAND = os.getenv("PYTHON_PATH", "python") # デフォルトは "python"
SERVER_SCRIPT = "./server_ball/mcp_server_ball.py"
SERVER_PARAMS = StdioServerParameters(
command=SERVER_COMMAND,
args=[SERVER_SCRIPT],
env=env_vars
)
LLM_MODEL_NAME ="gpt-4o"
# LLMに投入するシステムメッセージ
LLM_SYSTEM_MESSAGE = "あなたは、ユーザーの依頼を受けて、玉が入った箱の管理をするAIです。ユーザーの依頼に応じて、玉に文字を書いて箱に入れたり、その箱に特定の文字が書かれた玉が入っているか確認したり、玉の数を確認したりします。玉を箱に入れたら、何をしたのか報告してください。玉が入った箱の管理とは関係ないプロンプトにはツールを適用しないで、無視してください。"
# グローバル変数で状態を保持(各関数は自己完結するが、状態は共有)
available_tools = [] # MCP形式でのツール一覧
available_tools_gpt = [] # OpenAI形式でのツール一覧
available_resources = [] # リソース付属情報一覧
conversation_history = [] # LLMとMCPクライアントとのやりとりの履歴
is_initialized = False
def initialize_client():
"""
MCPクライアントの初期化、MCPサーバーからツール一覧と各リソースの付属情報一覧を取得する
"""
global available_tools, available_tools_gpt, available_resources, is_initialized
if is_initialized:
logger.info("既に初期化済みのため、再初期化をスキップします")
return True
# MCPクライアント初期化処理
async def async_init():
global available_tools, available_tools_gpt, available_resources
# MCPサーバーとMCPで通信
# MCPサーバーのためのプロセスを開始、MCPサーバーをスクリプト実行、MCPサーバーとの標準入出力通信ルートを構築
async with stdio_client(SERVER_PARAMS) as (read, write):
# MCPサーバーとのMCP通信セッションを構築
async with ClientSession(read, write) as session:
# MCPサーバーとの初期化ハンドシェイク
await session.initialize()
# ツール一覧取得
list_tool_result = await session.list_tools()
# 戻り値の型は、mcp.types.ListToolsResult
available_tools = list_tool_result.tools
# MCPサーバーから取得したMCP形式のツール一覧をOpenAI形式に変換
available_tools_gpt = [
{
"type": "function",
"name": tool.name,
"description": tool.description,
"parameters": tool.inputSchema
}
for tool in available_tools
]
# 各リソースの付属情報の一覧取得
# 本機能では、MCPサーバーのリソースの増減が無く常に一定なので、ここで取得
# MCPサーバーのリソースの増減がある場合は、ここではなく、必要に応じて取得
list_resource_result = await session.list_resources()
# 戻り値の型は、mcp.types.ListResourcesResult
available_resources = list_resource_result.resources
# async with ClientSessionを抜け、MCPサーバーとのMCP通信セッションが自動的に遮断される
# async with stdio_clientを抜け、MCPサーバーとの標準入出力通信ルートの遮断とサーバープロセス終了が自動で行われる
return True
try:
result = False
result = asyncio.run(async_init())
is_initialized = result
return result
except Exception as e:
logger.error(f"MCPクライアント(玉の箱の管理)初期化処理中にエラー: {type(e).__name__}: {e}")
return False
def process_user_prompt(user_input: str) -> str:
"""
ユーザー依頼を処理し、GPTにツールを選択させ、
必要なツール呼び出し・最終応答までを行う。
"""
if not is_initialized:
logger.info("MCPクライアント(玉の箱の管理)が未初期化のため、初期化を実行")
initialize_client()
# ユーザーの依頼を会話履歴に追加
conversation_history.append({"role": "user", "content": user_input})
async def async_process():
try:
# clientインスタンスを生成
client = AsyncOpenAI(api_key=os.environ.get("OPENAI_API_KEY"))
# GPTが、ユーザー依頼に対応するのに適切なツールをツール一覧から選択、引数も生成
response = await client.responses.create(
model=LLM_MODEL_NAME,
tools=available_tools_gpt,
input=[{"role": "system", "content": LLM_SYSTEM_MESSAGE}] + conversation_history
)
# response.outputはlist
# GPTからの返答responseは、「自然言語でのメッセージ」or「選択したツール」のどちらか
response_out_type = response.output[0].type
# response_out_typeが"message"なら「自然言語でのメッセージ」 → ツールは選択せず
# response_out_typeが"function_call"なら「選択したツール」 → 以下の、MCPサーバーにツールを実行させる処理に入る
# MCPサーバーとMCPで通信
# MCPサーバーのためのプロセスを開始、MCPサーバーをスクリプト実行、MCPサーバーとの標準入出力通信ルートを構築
async with stdio_client(SERVER_PARAMS) as (read, write):
# MCPサーバーとのMCP通信セッションを構築
async with ClientSession(read, write) as session:
# MCPサーバーとの初期化ハンドシェイク
await session.initialize()
while response_out_type == "function_call":
# GPTが何らかのツールを選択
tool_calls = response.output
# GPTが選択したツールを順次実行
for call in tool_calls:
if call.type != "function_call":
continue
# 会話履歴にツール選択結果を追加
# callの型は、openai.types.responses.response_function_tool_call.ResponseFunctionToolCall
conversation_history.append(call)
# GPTが選択したツールの名前を取得
tool_name = call.name
# GPTが生成したツールの引数を取得
arguments = json.loads(call.arguments)
# GPTより採番されたcall_idを取得
call_id = call.call_id
# MCPサーバーにツールを実行させる
result_content = await session.call_tool(tool_name, arguments)
# MCPサーバーでのツール実行結果のjson文字列(欲しい答えはこれ)を戻り値result_contentから抽出
# 戻り値result_contentはmcp.types.CallToolResult型
# result_content.contentはlistで、MCPサーバーのcall_toolのシグネチャの戻り値の型list[Union[mcp.types.TextContent, mcp.types.ImageContent, mcp.types.EmbeddedResource]]そのもの
# MCPサーバー「mcp_server_ball」のcall_toolの実行結果はmcp.types.TextContentオブジェクトが1個のみのlistであることがわかっているので、以下のような簡素な抽出法でよい。
result_text = result_content.content[0].text
# JSONパースできる場合、status確認(ツール実行時にエラーが起こったかどうか)
# しかし、is_errorフラグそのものには使いみちは無いので、やらなくてもいいかも。
# ツール実行時のエラーには、玉に書く文字列(玉のキー文字列)の重複、というのがあり、必ずしもこのユーザー依頼対応の順次処理を止めるべきものとは限らない。
try:
result_json = json.loads(result_text)
is_error = (result_json.get("status") == "error")
e_msg_tool = result_json.get("data").get("error") if is_error else ""
except Exception:
logger.warning("結果文字列をJSONに変換できず")
# ツール実行結果文字列のJSON変換に失敗しているので、ツール実行時にエラー発生とみなす
is_error = True
e_msg_tool = "ツール実行時に不明なエラー"
if is_error:
# ツール実行時にエラーが起こっても処理は止めない。
# ツール実行のこのターン(forループの)でエラーが起こっても、次のターンに影響を及ぼさない。
logger.warning(f"ツール実行時のエラー:{e_msg_tool}")
# 会話履歴にツール実行結果を追加
conversation_history.append({
"type": "function_call_output",
"call_id": call_id,
"output": result_text
})
# for ループを抜けた。GPTにより選択されていた全ツールの実行を完了した。
# 最終応答の生成 or 次のFunction callのループ
# <最終応答の生成>
# ユーザー依頼が、実行すべき全ツールを確定させるものの場合
# (「玉に鈴木と書いて箱に入れて。箱に今玉は何個ある?」)
# このresponseのcreateでは、全ツールの実行が完了したので、最終応答の生成となる。
# response.output[0].typeは"message"になる。
# <次のFunction callのループ>
# ユーザー依頼が、実行すべきツールを全確定させない(あるツールの実行で、次のツールが決まる)ものの場合
# (「箱に鈴木と書いてある玉が無ければ追加して」)
# もし、そのツールの実行結果が、さらなるツールの実行を要求する時(箱に鈴木と書いてある玉が無かった)、
# このresponseのcreateでは、最終応答の生成ではなく、次のFunction callのループとなる。
# response.output[0].typeは"function_call"になる。
response = await client.responses.create(
model=LLM_MODEL_NAME,
tools=available_tools_gpt,
input=[{"role": "system", "content": LLM_SYSTEM_MESSAGE}] + conversation_history
)
response_out_type = response.output[0].type
# response_out_typeが"message"なら、responseには最終応答が入っている。whileを抜ける。
# response_out_typeが"function_call"なら、responseには次に実行すべきツールが入っている。whileループをもう一回行う。
# while response_out_type == "function_call" ループを抜けた。
# async with ClientSessionを抜け、MCPサーバーとのMCP通信セッションが自動的に遮断される
# async with stdio_clientを抜け、MCPサーバーとの標準入出力通信ルートの遮断とサーバープロセス終了が自動で行われる
#最終応答の抽出
final_text = response.output_text
# 会話履歴に最終応答を追加
conversation_history.append({"role": "assistant", "content": final_text})
return final_text
except Exception as e:
logger.error(f"ユーザー依頼の処理中にエラー発生: {type(e).__name__}: {e}")
return f"[MCP] エラーが発生しました: {str(e)}"
try:
return asyncio.run(async_process())
except Exception as e:
logger.error(f"asyncio実行エラー: {type(e).__name__}: {e}")
return f"[MCP] 実行エラー: {str(e)}"
def read_resource_by_uri(uri: str) -> str:
"""
MCPサーバーから指定URIのリソース1個を取得する
"""
if not is_initialized:
logger.info("MCPクライアント(玉の箱の管理)が未初期化のため、初期化を実行")
initialize_client()
async def async_read():
try:
# MCPサーバーとMCPで通信
async with stdio_client(SERVER_PARAMS) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
# MCPサーバーからリソースを取得
uri_ob = parse_obj_as(AnyUrl, uri)
resource_data = await session.read_resource(uri_ob)
# resource_dataはmcp.types.ReadResourceResult型
# 欲しいリソースは文字列、resource_dataから抽出
return resource_data.contents[0].text
except Exception as e:
logger.error(f"リソース取得エラー: {type(e).__name__}: {e}")
return f"[MCP] リソース取得エラー: {str(e)}"
try:
return asyncio.run(async_read())
except Exception as e:
logger.error(f"asyncio実行エラー: {type(e).__name__}: {e}")
return f"[MCP] 実行エラー: {str(e)}"
AIエージェントとしての機能~ユーザーの依頼を捌く仕組み
ユーザーの自然言語での依頼から、AIエージェントからユーザーへの返答までは、以下の流れになります。
主要部分は、OpenAIの「Function calling」です。
LLMの判断材料になるツール一覧の取得と、実際のツールの実行、の2ヶ所で、MCPサーバーが絡みます。
1.事前にMCPサーバーからツール一覧を取得し、保持
MCPクライアント初期化処理のinitialize_client
関数において、MCPサーバーからツール一覧(全ツールの仕様書のようなもの)を取得し、OpenAI形式に変換します。
くわしくはこちら。
ここで取得して保持するツール一覧(OpenAI形式)は、後にユーザーから自然言語での依頼が投入された際、LLMのGPTに、その自然言語での依頼と一緒に投入し、どのツールが適切か選択させるのに使います。
~~~~~~
2.ユーザーがUIに依頼を投入し、MCPクライアントへとその依頼が渡される
UIからMCPクライアントのprocess_user_prompt関数に、ユーザーの依頼が送られます。
依頼は、「玉に岸田と書いて箱に入れて。玉は全部で何個になった?」とします。
3.LLMに、ユーザーの依頼を実現するツールの選択と引数の生成をさせる
ユーザーの依頼を会話履歴に追加します。
そして、保持してあるツール一覧(jsonのlist)とその会話履歴をGPTに投入し、以下の2つをさせます。
・ツールの選択
・選択したツールの引数の生成
GPTは、「ユーザーの自然言語での依頼」と「ツール一覧」を照合し、ツールの選択と引数の生成をします。
GPTがツール一覧(jsonのlist)で依拠するのは、以下の2つです。
・各ツールの要素「description」(そのツールの機能や果たす役割が書いてある)
⇒ ツールを選択するのに使用
・各ツールの入れ子オブジェクト「parameters」(そのツールの全引数について、その意味や型が書いてある)
⇒ 選択したツールの引数を生成するのに使用
ここでは、GPTは、以下のようにツールを選択し、それらツールの引数を生成したとします。
GPTが選択したツール | GPTが生成した引数 |
---|---|
add_ball | text=”岸田” |
get_balls_status | count_only=True |
ユーザーの依頼は、「玉に岸田と書いて箱に入れて。玉は全部で何個になった?」で、1つの依頼文の中に2つの依頼が含まれています。
なので、GPTは、ツールを2回分選択したわけです。
この3を、OpenAI用語では「Function calling」、Anthropic用語では「Tool use」と言います。どちらも同じ意味です。
LLMが、プロンプトとともに提供された関数(Function)情報一覧から、適切な関数を選択し、その関数の引数も生成することを言います。
4.MCPサーバーに、ツールを順次実行させる
以下のサイクルを、選択されたツール全てについて、繰り返します。
↓↓↓ ツール1回分のサイクル はじめ ↓↓↓
選択されたツールとその引数を会話履歴に追加
↓
選択されたツールを、引数を添えて、MCPサーバーに実行させる
↓
そのツールの実行結果を会話履歴に追加
↑↑↑ ツール1回分のサイクル 終わり ↑↑↑
今の例では、以下のようになります。
ツール「add_ball」が選択され、引数は「count_only=True」であることを会話履歴に追加
↓
MCPサーバーに、ツール「add_ball」を引数(text=”岸田”)を添えて実行させる
↓
ツール「add_ball」の実行結果「成功」を会話履歴に追加
↓
ツール「get_balls_status」が選択され、引数は「count_only=True」であることを会話履歴に追加
↓
MCPサーバーに、ツール「get_balls_status」を引数(count_only=True)を添えて実行させる
↓
ツール「get_balls_status」の実行結果「4個」を会話履歴に追加
5.LLMに、ユーザーへの最終応答を生成させる
GPTに、会話履歴をもとに、自然言語での最終応答を生成させます。
この時点での会話履歴は以下の通りです(上に行くほど新しい)。
5)ツール「get_balls_status」の実行結果「4個」
4)ツール「get_balls_status」が選択され、引数は「count_only=True」
3)ツール「add_ball」の実行結果「成功」
2)ツール「add_ball」が選択され、引数は「text=”岸田”」
1)ユーザーの依頼「玉に岸田と書いて箱に入れて。玉は全部で何個になった?」
この会話履歴を踏まえたGPTの最終応答は、「玉に「岸田」と書いて箱に入れました。箱の中には全部で4個の玉があります。」となりました。
このGPTの最終応答を会話履歴に追加します。
結果、この時点での会話履歴は以下の通りとなります(上に行くほど新しい)。
6)GPTの最終応答「玉に「岸田」と書いて箱に入れました。箱の中には全部で4個の玉があります。」
5)ツール「get_balls_status」の実行結果「4個」
4)ツール「get_balls_status」が選択され、引数は「count_only=True」
3)ツール「add_ball」の実行結果「成功」
2)ツール「add_ball」が選択され、引数は「text=”岸田”」
1)ユーザーの依頼「玉に岸田と書いて箱に入れて。玉は全部で何個になった?」
この会話履歴の状態で、次の新たなユーザー依頼に対処していきます。
6.UIに最終応答を返す
MCPクライアントからUIに最終応答を返します。
MCPクライアントのprocess_user_prompt関数の戻り値が最終応答です。
MCPクライアント実装のポイント
MCPクライアント実装のポイントを列挙していきます。
MCPクライアント実装の際の参考にしてください。
その1:MCPサーバーとのsessionは、定型文で都度構築
その2:MCPサーバー4関数の戻り値のトリセツ
その3:MCPクライアント起動時に、MCPサーバーからツール一覧を取得
その1:MCPサーバーとのsessionは、定型文で都度構築
MCPサーバーと何かやり取りする時は、その都度、MCPサーバーとのsessionを構築します(そしてやり取りが終わったら自動的にsessionが破棄されます)。
実装においては単純です。
MCPサーバーと何かやり取りする場面毎に、以下の「定型文」を作ってください。
「定型文」とは別に、まずは以下を1ヶ所だけ記載してください。
MCPサーバーを独立プロセスでスクリプト実行するためのパラメーター設定にすぎません。
環境変数「PYTHON_PATH」は、心配なら設定してください。
これは1ヶ所だけ。MCPサーバーを独立プロセスでスクリプト実行するためのパラメーター設定。
# 環境変数の読み込み
load_dotenv()
env_vars = os.environ.copy()
# MCPサーバーの設定(グローバル変数として定義)
SERVER_COMMAND = os.getenv("PYTHON_PATH", "python") # デフォルトは "python"
SERVER_SCRIPT = "./server_ball/mcp_server_ball.py" # MCPサーバーの.pyの場所
SERVER_PARAMS = StdioServerParameters(
command=SERVER_COMMAND,
args=[SERVER_SCRIPT],
env=env_vars
)
(環境変数をコピーして、StdioServerParametersのenv引数に渡しているのは、MCPサーバー.pyの場所がこのMCPクライアント.pyの場所と異なり(./server_ball/mcp_server_ball.py)、MCPサーバー.py側で確実に.envファイルを読むことができないからです。)
MCPサーバーとやり取りする場面では、以下の「定型文」にしてください。
MCPサーバーとやり取りする場面は以下の「定型文」にする。try~exceptまで含めて全部が「定型文」。
async def some_process(): #これが「定型文」。関数名「some_process」を適宜変更されたい。
# MCPサーバーとMCPで通信
# MCPサーバーのためのプロセスを開始、MCPサーバーをスクリプト実行、MCPサーバーとの標準入出力通信ルートを構築
async with stdio_client(SERVER_PARAMS) as (read, write):
# MCPサーバーとのMCP通信セッションを構築
async with ClientSession(read, write) as session:
# MCPサーバーとの初期化ハンドシェイク
await session.initialize()
# ここに、MCPサーバーとのやり取りを記述する。
# ここでは一例として、リソース関連のやり取りをMCPサーバーと行う、とする。
# MCPサーバーからリソース付属情報一覧を取得。MCPサーバーの@server.list_resources()を呼ぶ。
res_info_list = session.list_resources()
# 欲しいリソースのURIをres_info_listから抽出。ここら辺の細かいことは省略。
# MCPサーバーから欲しいリソースを取得。MCPサーバーの@server.read_resource()を呼ぶ。
res = session.read_resource(uri)
some_result = resを使った何かの演算
# async with ClientSessionを抜け、MCPサーバーとのMCP通信セッションが自動的に遮断される
# async with stdio_clientを抜け、MCPサーバーとの標準入出力通信ルートの遮断とサーバープロセス終了が自動で行われる
return some_result
try:
result = asyncio.run(some_process())
return result
except Exception as e:
logger.error("エラー")
実際には、(非同期ではなく)同期関数の中にこの「定型文」が丸々置かれるはずです(非同期関数async def
をasyncio.run
で実行しているから)。
MCPサーバーに何か用事があるたびに、0からMCPサーバーとのsessionを構築し直し、その用事が終わったら、sessionが自動的に破棄される(sessionの使い捨て)、ということになります。
そしてそれを、上記の「定型文」で行う、ということです。
本稿の実装例では、以下3ヶ所で、同じ「定型文」で、上記の「MCPサーバーとのsessionの構築&使い捨て」をしています。
・同期関数def initialize_client
内のasync def async_init
・同期関数def process_user_prompt
内のasync def async_process
・同期関数def read_resource_by_uri
内のasync def async_read
(簡略コード)MCPサーバーとやり取りをしている3ヶ所で、同じ「定型文」を使用
def initialize_client():
"""
MCPクライアントの初期化、MCPサーバーからツール一覧と各リソースの付属情報一覧を取得する
"""
# MCPクライアント初期化処理
async def async_init():
# MCPサーバーとMCPで通信
# MCPサーバーのためのプロセスを開始、MCPサーバーをスクリプト実行、MCPサーバーとの標準入出力通信ルートを構築
async with stdio_client(SERVER_PARAMS) as (read, write):
# MCPサーバーとのMCP通信セッションを構築
async with ClientSession(read, write) as session:
# MCPサーバーとの初期化ハンドシェイク
await session.initialize()
(MCPサーバーとやり取り)
# async with ClientSessionを抜け、MCPサーバーとのMCP通信セッションが自動的に遮断される
# async with stdio_clientを抜け、MCPサーバーとの標準入出力通信ルートの遮断とサーバープロセス終了が自動で行われる
return True
try:
result = False
result = asyncio.run(async_init())
is_initialized = result
return result
except Exception as e:
logger.error(f"MCPクライアント(玉の箱の管理)初期化処理中にエラー: {type(e).__name__}: {e}")
return False
def process_user_prompt(user_input: str) -> str:
"""
ユーザー依頼を処理し、GPTにツールを選択させ、
必要なツール呼び出し・最終応答までを行う。
"""
async def async_process():
try:
# MCPサーバーとMCPで通信
async with stdio_client(SERVER_PARAMS) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
(MCPサーバーとやり取り)
except Exception as e:
logger.error(f"ユーザー依頼の処理中にエラー発生: {type(e).__name__}: {e}")
return f"[MCP] エラーが発生しました: {str(e)}"
try:
return asyncio.run(async_process())
except Exception as e:
logger.error(f"asyncio実行エラー: {type(e).__name__}: {e}")
return f"[MCP] 実行エラー: {str(e)}"
def read_resource_by_uri(uri: str) -> str:
"""
MCPサーバーから指定URIのリソース1個を取得する
"""
async def async_read():
try:
# MCPサーバーとMCPで通信
async with stdio_client(SERVER_PARAMS) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
(MCPサーバーとやり取り)
except Exception as e:
logger.error(f"リソース取得エラー: {type(e).__name__}: {e}")
return f"[MCP] リソース取得エラー: {str(e)}"
try:
return asyncio.run(async_read())
except Exception as e:
logger.error(f"asyncio実行エラー: {type(e).__name__}: {e}")
return f"[MCP] 実行エラー: {str(e)}"
async with stdio_client(SERVER_PARAMS) as (read, write):
で、MCPサーバーのためのプロセスを立てて、そのMCPサーバーをスクリプト実行しています。
MCPサーバーのここで書いた通り、
このMCPクライアントの起動方法に関わらず、MCPサーバー側のif __name__ == "__main__":
が必ず実行され、MCPサーバーは標準入出力を介した待ち受け状態になります。
※sessionの使い捨てについて
以下は、興味のある人だけ。
せっかくMCPサーバーのプロセスを立てて、MCPサーバーとの標準入出力を介したsessionを確立したのだから、使い捨てにせずにこれを使い回したい、と思うのが自然です。
筆者は色々試しましたが、「SDKでは、それはやれないようになっている」と結論付けました。
SDKの(標準入出力の)設計思想は、どうも「都度確立&使い捨て」のようです。
例えば、stdio_client。
以下は必ずエラーになる。stdio_clientはasync with stdio_clientと呼ぶことがSDKで決まっている。
generator = stdio_client(SERVER_PARAMS)
(read_stream, write_stream) = await anext(generator)
stdio_clientはasync with stdio_client
と呼ぶことがSDKで決まっています。async with stdio_client
ブロックを抜けると、自動的にMCPクライアントとの標準入出力通信も閉じられます。おそらくSDKは、この「自動で閉じる」を常にしたいのだと思います。
しかし、async with stdio_client
ブロックの中で、「任意のタイミングで」MCPサーバーとやり取りを行う「シンプルな」方法は、おそらく無いと思います。
SDKのみを使用する場合、sessionを使い回したいのなら、標準入出力ではなくhttpを使用するしかないのでは、と思います(そっちは未経験なのでよくわかりません)。
その2:MCPサーバー4関数の戻り値のトリセツ
MCPサーバー4関数とはこのことで、別プロセスにいるMCPクライアントから標準入出力経由で呼べるMCPサーバーの関数4つです。
MCPサーバーの関数の戻り値の扱いでよくある間違いに、以下のようなものがあります。
よくある間違い
# MCPサーバー上の
# @server.read_resource()
# async def read_resource(resource_uri: AnyUrl) -> str:
# を呼ぶ
ret = session.read_resource(uri) # 有効なuriとする
# 「async def read_resource(resource_uri: AnyUrl) -> str:」なんだから、retは文字列だろう!
# retを小文字変換
ret_lower = ret.lower() # エラー!retは文字列ではない!
「MCPクライアントとMCPサーバーは異なるプロセス上にあり、標準入出力を介してやり取りしている」
ということと、
「MCPのSDKが、そのやりとりのプロトコルの低階層部分を裏でやっておいてくれている」
ということを思い出してください。
MCPのSDKは、MCPサーバーでのこの@server.read_resource()
関数の実行結果の文字列を、MCPのある型でラップします。そのラップしたオブジェクトがretになります。なのでretは文字列ではないです。
標準入出力という道を、MCPサーバーでの関数の実行結果の文字列を乗せて、サーバープロセスからクライアントプロセスに向けて走るコンテナ車「ret」を想像してください。
今、例で挙げた@server.read_resource()
以外の3関数にも、同じことが言えます。
MCPクライアントが受け取る戻り値は、MCPサーバーでの関数実行結果そのものではない
関数のシグネチャでの戻り値の型と、MCPクライアントが受け取る戻り値の型は異なる
ということです。
以下に、MCPサーバーの4関数について、MCPクライアントが受け取る戻り値「ret」の型と、その戻り値「ret」からMCPサーバーでの関数実行結果を取り出す方法についてまとめました。
MCPサーバーの関数のデコレーター名 | MCPサーバーでの関数実行結果の型 (シグネチャでの戻り値の型) |
MCPクライアントが受け取る戻り値「ret」の型 | retの中から、MCPサーバーでの関数実行結果を取り出す方法 |
---|---|---|---|
@server.list_tools() |
list[mcp.types.Tool] |
mcp.types.ListToolsResult |
ret.tools |
@server.call_tool() |
list[Union[mcp.types.TextContent, mcp.types.ImageContent, mcp.types.EmbeddedResource]] |
mcp.types.CallToolResult |
※後述 |
@server.list_resources() |
list[mcp.types.Resource] |
mcp.types.ListResourcesResult |
ret.resources |
@server.read_resource() |
str |
mcp.types.ReadResourceResult |
ret.contents[0].text |
@server.call_tool()
の「後述」について。
この関数のシグネチャでの戻り値の型は、list[Union[mcp.types.TextContent, mcp.types.ImageContent, mcp.types.EmbeddedResource]]
です。
これは規定のもので、変えてはいけないです。
つまり、MCP汎用的には、mcp.types.TextContent
オブジェクト、 mcp.types.ImageContent
オブジェクト、mcp.types.EmbeddedResource
オブジェクトが、いくつでも入り乱れてlistに入っている可能性があり、そのlistがmcp.types.CallToolResult
オブジェクトに乗っけられてMCPクライアントに返ってくる、ということになります。
実際に何がlistに入ってきて、MCPクライアントがその中から何を求めるか、というのは、個々のアプリケーション固有の話になります。
なので、ここで統一的に「常にこうします」と記すことはできません。
本稿の実装例ではどうしているか、を書きましょう。
MCPサーバー側の@server.call_tool()
では、実際に以下のlistを返しています。[mcp.types.TextContent(type="text", text=json.dumps(result, ensure_ascii=False))]
listは固定で1要素のみ、その1要素は常にmcp.types.TextContent
オブジェクト、ということです。
これが、このMCPサーバーでの関数の実行結果になります。
MCPクライアント側で、関数の戻り値mcp.types.CallToolResult
オブジェクト「ret」からこの実行結果のlistを取り出すには、ret.content
をします。
さらにここから、MCPクライアントが本当に欲しい値(json文字列)を取り出すには、ret.content[0].text
をします。
「listは固定で1要素のみ、その1要素は常にmcp.types.TextContent
オブジェクト」で、mcp.types.TextContent
オブジェクトのtext属性に、MCPクライアントが本当に欲しい値(json文字列)があるからです。
その3:MCPクライアント起動時に、MCPサーバーからツール一覧を取得
MCPサーバーが提供するツール一覧は、アプリケーション動作中に変わることはまずありません。
ですので、MCPクライアント起動時にMCPサーバーから取得してしまい、ずっと保持しておいたほうがいいです。
ただ、本実装例のAIエージェント機能では、LLMにOpenAIのGPTを使っており、MCP形式でのツール一覧と、OpenAI形式のツール一覧では、その形式に差分があるため、OpenAI形式でのツール一覧に変換してから保持しています。
MCPクライアントの初期化関数initialize_client
において、MCPサーバーからツール一覧を取得し、それをOpenAI形式に変換します。
(簡略コード)initialize_client 該当箇所
def initialize_client():
"""
MCPクライアントの初期化、ツール一覧と各リソースの付属情報一覧を取得する
"""
# MCPクライアント初期化処理
async def async_init():
# MCPサーバーとMCPで通信
async with stdio_client(SERVER_PARAMS) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
# ツール一覧取得
list_tool_result = await session.list_tools()
available_tools = list_tool_result.tools
# MCPサーバーから取得したMCP形式のツール一覧をOpenAI形式に変換
available_tools_gpt = [
{
"type": "function",
"name": tool.name,
"description": tool.description,
"parameters": tool.inputSchema
}
for tool in available_tools
]
# 各リソースの付属情報の一覧取得
# 本機能では、MCPサーバーのリソースの増減が無く常に一定なので、ここで取得
# MCPサーバーのリソースの増減がある場合は、ここではなく、必要に応じて取得
list_resource_result = await session.list_resources()
available_resources = list_resource_result.resources
return True
try:
result = False
result = asyncio.run(async_init())
is_initialized = result
return result
except Exception as e:
logger.error(f"MCPクライアント(玉の箱の管理)初期化処理中にエラー: {type(e).__name__}: {e}")
return False
OpenAI形式に変換しているところは、具体的に以下のことをしています。
MCP形式のツール一覧に対して、
・個々のツールに、キーと値のペア「”type”: “function”」を挿入
・その中の入れ子オブジェクトである、そのツールの引数情報の要素名を「inputSchema」→「parameters」に差し替え
MCPクライアントが保持するツール一覧(OpenAI形式)
MCPクライアントが保持するツール一覧(OpenAI形式)
[
{
"type": "function",
"name": "add_ball",
"description": "玉に文字を書いて箱に入れる。(プロンプト例:玉に鈴木と書いて箱に入れて。)",
"parameters": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "箱に入れる玉に書く文字。"
}
},
"required": [
"text"
]
}
},
{
"type": "function",
"name": "get_balls_status",
"description": "箱に入っている玉や箱の状態を取得する。(プロンプト例:鈴木と書いてある玉はある?)(プロンプト例:今、玉は箱にいくつある?)",
"parameters": {
"type": "object",
"properties": {
"search_text": {
"type": "string",
"description": "この文字が書かれた玉を箱の中から検索(オプション)。"
},
"count_only": {
"type": "boolean",
"description": "箱の中の玉の数を取得するか(オプション)。"
}
}
}
}
]
ちなみに、このMCPクライアントでは、ツール一覧だけではなく、リソース付属情報一覧も同じ場所で事前に取得しています。
本実装例のAIエージェント機能では、MCPサーバー側のリソースに増減が無く常に一定であることが明確なので、ここでしています。
MCPサーバー側のリソースに増減がある場合は、ここではなく必要に応じて行ってください。
その他、個々の実装
process_user_prompt
関数でのFunction callingの補足説明
process_user_prompt
関数は、ユーザーの依頼を処理する、まさにこのMCPクライアントの核となる部分です。
ここで、LLMにGPTを使用してFunction callingをしています。
これについて簡単に補足説明します。
理解のポイントは、response.output[0].type
この関数では、2ヶ所で、GPTに対応response = await client.responses.create
をさせています。
1つ目の冒頭のものには、ユーザー依頼に対して対応させています。
2つ目のwhileループの終端あたりのには、ユーザー依頼と全ツールの実行結果に基づく対応をさせています。
「対応」とは、「自然言語でのメッセージを生成した」or「ツールを選択した」のどちらかです。response.output[0].type
が文字列「message」なら、対応「自然言語でのメッセージを生成した」。response.output[0].type
が文字列「function_call」なら、対応「ツールを選択した」。
まとめます。
GPT対応箇所 | 何に対する対応か |
response.output[0].type の値 |
どういう状況か |
---|---|---|---|
1つ目 関数冒頭 |
ユーザー依頼 | message | 玉の箱の管理とは無関係な依頼(天気とか)。→最終応答を生成した。 |
1つ目 関数冒頭 |
ユーザー依頼 | function_call | 玉の箱の管理に関係がある依頼なので、何らかのツールを選択した。 |
2つ目 whileループ終端 |
ユーザー依頼と全ツールの実行結果 | message | もう実行すべきツールが残っていないので、何もツールを選択しなかった→最終応答を生成した。 |
2つ目 whileループ終端 |
ユーザー依頼と全ツールの実行結果 | function_call | 実行すべきツールが「発生した」ので、そのツールを選択した。 ユーザー依頼が、あるツールの実行結果により次に実行すべきツールが決まるようなもの。 例)ユーザー依頼「鈴木と書いてある玉が無ければ追加して。」 鈴木と書いてある玉の有無をツールを実行して確認したところ、無いことが分かった(これがforループから出てきた状態)。なので次は、玉を箱に追加するツールの実行が必要、とGPTが判断して、玉を箱に追加するツールを選択。 |
最後の4パターン目が気付きにくいです。
whileループでやっていることは、こういうことです。
玉の箱の管理をするAIエージェントで遊んでみる!
せっかく作ったので、遊んでみました。
フツーにやっても面白くないので、ちょいと変化球を投げてみました。
第1の依頼)条件分岐で次にやることが決まる依頼、しかも全く別の依頼が同時にされる
AIエージェントは、まず「橋本」の玉の検索をし、その玉が無かったので、「橋本」の玉の追加をそのタイミングで決定、そして実際に「橋本」の玉の追加をした。
そして、全く別の依頼「箱の中の玉の名前はどうなった?」にも対処し、箱の中の全部の玉の名前を検索して列挙した。
これらの応答を1回にまとめて行った。
第2の依頼)機械的に収集したデータから判断させる
AIエージェントは、箱の中の全ての玉に書いてある名前(森、中曽根、鳩山(由)、小泉、小渕、岸田、橋本)を収集し、それをもとに、日本の歴代首相の名前だ、と推測・回答した。
第3の依頼)一般的知識と組み合わせる
AIエージェントは、日本の初代首相の名前は伊藤博文である、という一般的知識を使用して、依頼に応えた。
第4の依頼)1度に3個の依頼、しかも真ん中の依頼はAIエージェントの対応範囲外
AIエージェントは、無関係な真ん中の依頼以外の2個の依頼に一括して対応し、最終応答においてのみ、無関係な真ん中の依頼に応答した。
まあ、「感情を持たない」と言ってるのに「大好き」とも言ってること自体が矛盾に満ちているが・・。
これで、メイン機能「AIエージェントが玉の箱を管理」は全部終わりました。
次は、サブ機能「別AIがユーザーとAIエージェントとの会話を評論」を見てみましょう。
サブ機能「別AIがユーザーとAIエージェントとの会話を評論」について
このサブ機能は、AIエージェントではなく、MCP準拠のただのC/Sです。
「AIエージェント」という大きな雑音が取り払われ、剥き出しの鉄骨となったMCP準拠のC/Sを眺めることで、逆に、いろんな”創造的想像”がはたらくのではないでしょうか。
「この作りはあれに使える」とか、「これをこう追加すれば生まれ変わる」など。
「会話をLLMに評論させる」という機能から、「AI臭」が漂っていますが、あくまでもC/Sのプロトコルに過ぎないMCPとしては、別にAIなんか無くてもいいのです。
例えば、「サーバー側で、会話を何かの分析に使用する」「サーバー側で、会話を何かの暗号に置き換える」でもいいのです。AIはどこにもありません。
筆者が、このサブ機能のネタとして「LLMによる会話の評論」を選んだのは、単にそれが面白いと思ったからで、AIを使ってやろう、なんて意識は毛頭ありませんでした。
Anthropicが
MCP is an open protocol that standardizes how applications provide context to LLMs.
The Model Context Protocol (MCP) is built on a flexible, extensible architecture that enables seamless communication between LLM applications and integrations.
と言っているのは、単にMCPを開発した目的がこれ、ということか、LLM方面からのMCPへの需要が大きい、ということでしょう。しかしそれらはMCPの機能とは関係がないです。
そういうものを無視して、MCPを冷徹に「プロトコル」という機能的側面で見ると、本機能は「MCP準拠のただのC/S」と捉えられるし、眺めれば、いろんな”創造的想像”もはたらくかもしれません。
話が横道に逸れてしまいました。元に戻します。
挙動
「あなたとAIエージェントとの会話」を、別AIが評論します。
ただそれだけのシンプルな機能です。
AIエージェントではなく、MCP準拠のただのC/Sです。
評論は、辛口ではあるが深い哲学的な、しかも蘊蓄に富んだもので、関西弁で行われます。
故・竹村健一さんをイメージしました。パイプ片手に「大体やねぇ、」とか「これからはデリーシャスの時代や。」とは言いませんが・・。
MCPクライアントとMCPサーバー
MCPクライアントとMCPサーバーは、同一マシン内の別プロセスで走ります。
両者の通信は、標準入出力を介して行われます。
まずは、MCPサーバー(mcp_server_review.py)から実装を見て行きましょう。
サブ機能「別AIがユーザーとAIエージェントとの会話を評論」のMCPサーバーの実装
クリックして、サブ機能のMCPサーバーの実装を見る
MCPサーバーは、MCPクライアントから、玉の箱の管理をするAIエージェントとユーザーの会話を渡され、それを別AIに評論させます(ツールの実行)。
別AIとは、gpt-4.1です。
MCPサーバーが持つツール
会話をGPTに評論させる1個のみです。
ツール名 | 機能 |
---|---|
review_text | 渡された会話をgpt-4.1に評論させ、その評論結果を文字列で返します。 |
MCPサーバー「mcp_server_review.py」 コード全部
サブ機能「別AIがユーザーとAIエージェントとの会話を評論」のMCPサーバー(mcp_server_review.py)
import os
import json
import logging
import asyncio
from datetime import datetime
from typing import Dict, List, Optional, Any, Union
from mcp.server import Server
from mcp.server.lowlevel.server import NotificationOptions
import mcp.types as types
from pydantic import AnyUrl
from mcp.types import Resource
from dotenv import load_dotenv
from openai import OpenAI
# 環境変数の読み込み
# これが意味があるかどうかは、MCPクライアントがこのMCPサーバーをどう起動するかに依存
load_dotenv()
# ロギング設定(出力フォーマット追加)
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s [MCP-SERVER-REVIIEW] %(message)s"
)
logger = logging.getLogger("mcp-server-review")
# サーバーの初期化
server = Server("mcp-review-server")
LLM_MODEL_NAME ="gpt-4.1"
# LLMに投入するシステムメッセージ
LLM_SYSTEM_MESSAGE = "あなたは、AIと人間との会話を評論するAIです。主に、玉が入った仮想的な箱の管理をするAIと、そのAIに(箱に玉を入れさせたり箱の中の玉の数を数えさせたりといった)依頼をする人間との間の会話です。辛口ではあるが深い哲学的な、しかも蘊蓄に富んだ評論を、関西弁で行います。評論は150字程度で濃密なものを。"
# リソース定義 - URIによって識別されるリソース
# このMCPサーバーではリソースは無い。空の辞書のまま。
resources = {}
async def review_text(text: str) -> Dict[str, Any]:
"""渡された会話をLLMが評論する"""
# LLMの準備。
# このMCPサーバーは、MCPクライアントとのセッションごとに刷新される。
# LLMの使用も、このreview_text内でのみ。
# 従って、事前にLLMの準備をすることに意味は無い。
# load_dotenv()が意味のある結果を生むかわからないので、APIキーを取り出す処理を切り出し、キーが取れなかったらその旨エラーで通知。
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
logger.error("環境変数 OPENAI_API_KEY が読めませんでした。")
return {"status": "error", "data": {"text": "[レビュー失敗] OPENAI_API_KEY が読めませんでした"}}
# clientインスタンスを生成
client = OpenAI(api_key=api_key)
try:
# 評論をさせる
response = client.responses.create(
model=LLM_MODEL_NAME,
input=[
{"role": "system", "content": LLM_SYSTEM_MESSAGE},
{"role": "user", "content": text}
],
temperature=0.7
)
# LLMの評論本文を抽出
res_review_str = response.output_text
return {"status": "success", "data": {"text": res_review_str}}
except Exception as e:
logger.error(f"OpenAI API呼び出しでエラー: {type(e).__name__}: {e}")
return {"status": "error", "data": {"text": f"[レビュー失敗] {str(e)}"}}
@server.list_tools()
async def list_tools() -> List[types.Tool]:
"""
利用可能なツールの仕様のリストを返す
"""
return [
types.Tool(
name="review_text",
description="渡された会話をLLMが評論して、返す",
inputSchema={
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "評論の対象になる文章"
}
},
"required": ["text"]
}
)
]
# ツール呼び出し
@server.call_tool()
async def call_tool(name: str, arguments: Dict[str, Any]) -> List[Union[types.TextContent, types.ImageContent, types.EmbeddedResource]]:
"""
指定されたツールを引数を使って実行する
LLMが選択したツールと生成した引数に基づいて処理を行う
"""
# 各ツールを呼び出し
result = None
if name == "review_text":
text = arguments.get("text")
result = await review_text(text)
else:
result = {"status": "error", "data": {"error": "不明なツール"}}
# 結果を返す
return [types.TextContent(type="text", text=json.dumps(result, ensure_ascii=False))]
# 各リソースの付属情報のリスト
@server.list_resources()
async def list_resources_info() -> list[Resource]:
# MCPサーバーにリソースそのものが無い場合、空のリストを返す
return []
# 特定のリソースの取得
@server.read_resource()
async def read_resource(resource_uri: AnyUrl) -> str:
# MCPサーバーにリソースそのものが無い場合、すべてのURIは無効
raise ValueError(f"リソースが見つかりません: {resource_uri}")
# メイン関数 - MCPサーバーが独立プロセスとしてスクリプト実行された時の事実上のエントリーポイント
async def main():
"""
MCPサーバーを実行するメイン関数
独立したプロセスとして実行された際のエントリーポイント
"""
# MCPサーバー実行
from mcp.server.stdio import stdio_server
try:
# MCPクライアントと標準入出力を介したやり取りができる状態にする
async with stdio_server() as (read_stream, write_stream):
from mcp.server.models import InitializationOptions
# MCPサーバーを実際に動かして、標準入出力を介した待ち受け状態を作る
await server.run(
read_stream,
write_stream,
InitializationOptions(
server_name="mcp-review-server",
server_version="1.0.0",
capabilities=server.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={}
)
)
)
except Exception as e:
logger.error(f"MCPサーバー(会話の評論)の起動中にエラーが発生しました: {e}")
raise
# MCPサーバーが独立プロセスとしてスクリプト実行された時のエントリーポイント
if __name__ == "__main__":
asyncio.run(main())
MCPサーバー実装のポイント
MCPサーバー実装のポイントを列挙していきます。
MCPサーバー実装の際の参考にしてください。
※ほとんど、メイン機能のAIエージェントのMCPサーバーの方で既に説明しています。
多くはそちらにリンクで飛ばすだけになります。
その1:if __name__ == "__main__"
でサーバーを起動、MCPクライアントからの要求待ち受け状態を作る
その2:MCPサーバーに必須の@server.
付き4関数を必ず実装
その1:if __name__ == "__main__
“でサーバーを起動、MCPクライアントからの要求待ち受け状態を作る
その2:MCPサーバーに必須の@server.
付き4関数を必ず実装
その他、個々の実装
@server.call_tool()のasync def call_tool(・・)
@server.list_resources()のasync def list_resources_info()
該当箇所 @server.list_resources()
# 各リソースの付属情報のリスト
@server.list_resources()
async def list_resources_info() -> list[Resource]:
# MCPサーバーにリソースそのものが無い場合、空のリストを返す
return []
このMCPサーバーには、リソースそのものがありません。
リソースは、必ず定義しなければならないものではないです。
このMCPサーバーのように「ただ処理をして返すだけ」の場合は、リソースは定義のしようが無いです。
MCPサーバーにリソースそのものが無い場合は、@server.list_resources()
は、常に空のリストを返します。
@server.read_resource()のasync def read_resource(・・)
該当箇所 @server.read_resource()
# 特定のリソースの取得
@server.read_resource()
async def read_resource(resource_uri: AnyUrl) -> str:
# MCPサーバーにリソースそのものが無い場合、すべてのURIは無効
raise ValueError(f"リソースが見つかりません: {resource_uri}")
このMCPサーバーには、リソースそのものがありません。
リソースは、必ず定義しなければならないものではないです。
このMCPサーバーのように「ただ処理をして返すだけ」の場合は、リソースは定義のしようが無いです。
MCPサーバーにリソースそのものが無い場合は、@server.read_resource()
は、常にエラー(該当するURIが無い)を返します。
以上で、
サブ機能「別AIがユーザーとAIエージェントとの会話を評論」のMCPサーバーの実装の説明は終わりです。
次は、同じサブ機能のMCPクライアントの実装です。
サブ機能「別AIがユーザーとAIエージェントとの会話を評論」のMCPクライアントの実装
クリックして、サブ機能のMCPクライアントの実装を見る
本機能はAIエージェントではない=特定の機能を果たすことが初めから決まっているものです。
なので、このMCPクライアントも単純で、ユーザーとAIエージェントとの会話文をMCPサーバーに渡し、評論させる、ということしかしません。
MCPクライアント「mcp_client_review.py」 コード全部
サブ機能「別AIがユーザーとAIエージェントとの会話を評論」のMCPクライアント(mcp_client_ball.py)
import asyncio
import json
import os
import logging
from mcp.client.session import ClientSession
from mcp.client.stdio import StdioServerParameters, stdio_client
from dotenv import load_dotenv
# 環境変数の読み込み
load_dotenv()
env_vars = os.environ.copy()
# ロギング設定
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s [MCP-CLIENT-REVIEW] %(message)s"
)
logger = logging.getLogger("mcp-client-review")
# MCPサーバーの設定(グローバル変数として定義)
SERVER_COMMAND = os.getenv("PYTHON_PATH", "python") # デフォルトは "python"
SERVER_SCRIPT = "./server_review/mcp_server_review.py"
SERVER_PARAMS = StdioServerParameters(
command=SERVER_COMMAND,
args=[SERVER_SCRIPT],
env=env_vars
)
# グローバル変数で状態を保持(各関数は自己完結するが、状態は共有)
available_resources = [] # リソース付属情報一覧
is_initialized = False
def initialize_client():
"""
MCPクライアントを初期化し、リソース付属情報一覧を取得する
"""
global available_resources, is_initialized
if is_initialized:
logger.info("既に初期化済みのため、再初期化をスキップします")
return True
async def async_init():
global available_resources
# MCPサーバーとMCPで通信
# MCPサーバーのためのプロセスを開始、MCPサーバーをスクリプト実行、MCPサーバーとの標準入出力通信ルートを構築
async with stdio_client(SERVER_PARAMS) as (read, write):
# MCPサーバーとのMCP通信セッションを構築
async with ClientSession(read, write) as session:
# MCPサーバーとの初期化ハンドシェイク
await session.initialize()
# ツール一覧取得は行わない
# 本機能はAIエージェントではないので、ツール一覧を取得しても使いみちは無く、不要である。
# 各リソースの付属情報の一覧取得
# 本機能では、そもそもMCPサーバー側にリソースとして定義されるものが無いが、一応やっておく。空のListを含んだmcp.types.ListResourcesResultが返ってくる。
# 各リソースの付属情報が固定ではない(リソースそのものが増減したりする)場合は、ここではなく、必要に応じて取得
list_resource_result = await session.list_resources()
# 戻り値の型は、mcp.types.ListResourcesResult
available_resources = list_resource_result.resources
# async with ClientSessionを抜け、MCPサーバーとのMCP通信セッションが自動的に遮断される
# async with stdio_clientを抜け、MCPサーバーとの標準入出力通信ルートの遮断とサーバープロセス終了が自動で行われる
return True
try:
result = False
result = asyncio.run(async_init())
is_initialized = result
return result
except Exception as e:
logger.error(f"MCPクライアント(会話の評論)初期化処理中にエラー: {type(e).__name__}: {e}")
return False
def get_review_llm(conversation: str) -> str:
"""
渡された会話をMCPサーバーに投げて評論させる。
"""
if not is_initialized:
logger.info("MCPクライアント(会話の評論)が未初期化のため、初期化を実行")
initialize_client()
async def async_process():
# MCPサーバーとMCPで通信
# MCPサーバーのためのプロセスを開始、MCPサーバーをスクリプト実行、MCPサーバーとの標準入出力通信ルートを構築
async with stdio_client(SERVER_PARAMS) as (read, write):
# MCPサーバーとのMCP通信セッションを構築
async with ClientSession(read, write) as session:
# MCPサーバーとの初期化ハンドシェイク
await session.initialize()
# MCPサーバーのcall_tool()を使用し、会話の評論をさせてその結果を得る。
# 本機能はAIエージェントではない。やりたいこと(実行すべきツール)は既に決まっているので、ツール名と引数は埋め込みとする。
tool_name = "review_text"
arguments = {"text": conversation}
result_content = await session.call_tool(tool_name, arguments)
# 戻り値result_contentはmcp.types.CallToolResult型
# result_content.contentはlistで、MCPサーバーのcall_toolのシグネチャの戻り値の型list[Union[mcp.types.TextContent, mcp.types.ImageContent, mcp.types.EmbeddedResource]]そのもの
# MCPサーバー「mcp_server_review」のcall_toolの実行結果はmcp.types.TextContentオブジェクトが1個のみのlistであることがわかっているので、以下のような簡素な抽出法でよい。
result_text = result_content.content[0].text
# JSON文字列であるresult_text中の["data"]["text"]に、評論本文がある
# JSONパースして中のtextを取り出し、statusも確認(ツール実行時にエラーが起こったかどうか)
try:
result_json = json.loads(result_text)
is_error = (result_json.get("status") == "error")
review_text_result = result_json["data"]["text"]
except Exception as e:
logger.warning("結果文字列をJSONに変換できず")
review_text_result = "LLM評論処理結果文字列のJSON変換失敗。"
# ツール実行結果文字列のJSON変換に失敗しているので、ツール実行時にエラー発生とみなす
is_error = True
if is_error:
# review_text_resultに、エラーの内容が入っているはず。
review_text_result = "LLM評論処理中にエラー発生。" + review_text_result
return review_text_result
try:
return asyncio.run(async_process())
except Exception as e:
logger.error(f"asyncio実行エラー: {type(e).__name__}: {e}")
return f"[MCP] 実行エラー: {str(e)}"
ユーザーとAIエージェントとの会話の評論を得るまで
UIから、ユーザーとAIエージェントとの会話の評論を得るように指示
↓
MCPクライアントは、MCPサーバーで評論を行うツール「review_text」の名前を、その会話文を添えて、MCPサーバーの関数@server.call_tool()
に入れて実行させる
↓
評論文がMCPサーバーから返ってくると、それをUIに返す
これらは、MCPクライアントの関数get_review_llm
で行われます。
MCPクライアント実装のポイント
MCPクライアント実装のポイントを列挙していきます。
MCPクライアント実装の際の参考にしてください。
※ほとんど、メイン機能のAIエージェントのMCPクライアントの方で既に説明しています。
多くはそちらにリンクで飛ばすだけになります。
その1:MCPサーバーとのsessionは、定型文で都度構築
その2:MCPサーバー4関数の戻り値のトリセツ
その1:MCPサーバーとのsessionは、定型文で都度構築
その2:MCPサーバー4関数の戻り値のトリセツ
その他、個々の実装
MCPクライアント自体の初期化処理の要不要
AIエージェントではない場合、一般的には、ツール一覧の使いみちが無く、MCPクライアントの初期化処理では、AIエージェント機能のMCPクライアントがやったようにMCPサーバーからツール一覧を取得する、ということを行いません。
従って、要不要は、MCPサーバーからリソース付属情報一覧を取得するかどうか、になります。
MCPサーバーのリソースが増減しない=リソース付属情報一覧が固定、であるならば、初期化処理を作って、そこでMCPサーバーからリソース付属情報一覧を取得すればいいと思います。
本実装例のこのMCPクライアントでは初期化処理initialize_client
を設けています。
しかし本来はこの初期化処理は要らないです。MCPサーバーにはリソースそのものがありません。
敢えてやるとどういうものか、示しておきました。
ユーザーとAIエージェントとの会話を評論させて遊んでみる!
こっちも、せっかく作ったので、遊んでみました。
<評論の対象>ユーザーとAIエージェントとの会話
何回か、この会話を評論させてみます。
評論バージョン1
評論バージョン2
評論バージョン3
評論バージョン4
評論バージョン5
これで、ミニアプリ「玉の箱を管理するAIエージェント」のMCP関連部分は、全部見終わりました!
おつかれさまでした。
次からは、FastMCPを使おう!
Views: 5